CPU 使用率高就一定有效率吗?

CPU 使用率高就一定有效率吗?

背景

最近碰到一个客户业务跑在8C ECS 上,随着业务压力增加 CPU使用率也即将跑满,于是考虑将 8C 升级到16C,事实是升级后业务 RT 反而略有增加,这个事情也超出了所有程序员们的预料,所以我们接下来分析下这个场景

分析

通过采集升配前后、以前和正常时段的火焰图对比发现CPU 增加主要是消耗在 自旋锁上了:

image-20240202085410669

用一个案例来解释下自旋锁和锁,如果我们要用多线程对一个整数进行计数,要保证线程安全的话,可以加锁(synchronized), 这个加锁操作也有人叫悲观锁,抢不到锁就让出这个线程的CPU 调度(代价上下文切换一次,几千个时钟周期)

另外一种是用自旋锁(CAS、spin_lock) 来实现,抢不到锁就耍赖占住CPU 死磕不停滴抢(CPU 使用率一直100%),自旋锁的设计主要是针对抢锁概率小、并发低的场景。这两种方案针对场景不一样各有优缺点

假如你的机器是8C,你有100个线程来对这个整数进行计数的话,你用synchronized 方式来实现会发现CPU 使用率永远达不到50%

image-20240202090428778

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#taskset -a -c 56-63 java LockAccumulator 100 1000000000
累加结果: 1000000000 and time:84267

Performance counter stats for 'taskset -a -c 56-63 java -Djava.library.path=. LockAccumulator 100 100000000':

17785.271791 task-clock (msec) # 2.662 CPUs utilized
110,351 context-switches # 0.006 M/sec
10,094 cpu-migrations # 0.568 K/sec
11,724 page-faults # 0.659 K/sec
44,187,609,686 cycles # 2.485 GHz
<not supported> stalled-cycles-frontend
<not supported> stalled-cycles-backend
22,588,807,670 instructions # 0.51 insns per cycle
6,919,355,610 branches # 389.050 M/sec
28,707,025 branch-misses # 0.41% of all branches

如果我们改成自旋锁版本的实现,8个核CPU 都是100%

image-20240202090714845

以下代码累加次数只有加锁版本的10%,时间还长了很多,也就是效率产出实在是低

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#taskset -a -c 56-63 java -Djava.library.path=. SpinLockAccumulator 100 100000000
累加结果: 100000000
操作耗时: 106593 毫秒

Performance counter stats for 'taskset -a -c 56-63 java -Djava.library.path=. SpinLockAccumulator 100 100000000':

85363.429249 task-clock (msec) # 7.909 CPUs utilized
23,010 context-switches # 0.270 K/sec
1,262 cpu-migrations # 0.015 K/sec
13,403 page-faults # 0.157 K/sec
213,191,037,155 cycles # 2.497 GHz
<not supported> stalled-cycles-frontend
<not supported> stalled-cycles-backend
43,523,454,723 instructions # 0.20 insns per cycle
10,306,663,291 branches # 120.739 M/sec
14,704,466 branch-misses # 0.14% of all branches

代码

放在了github 上,有个带调X86 平台 pause 指令的汇编,Java 中要用JNI 来调用,ChatGPT4帮我写的,并给了编译、运行方案:

1
2
3
4
5
6
7
8
9
10
javac SpinLockAccumulator.java
javah -jni SpinLockAccumulator

# Assuming GCC is installed and the above C code is in SpinLockAccumulator.c
gcc -shared -o libpause.so -fPIC SpinLockAccumulator.c

java -Djava.library.path=. SpinLockAccumulator

实际gcc编译要带上jdk的头文件:
gcc -I/opt/openjdk/include/ -I/opt/openjdk/include/linux/ -shared -o libpause.so -fPIC SpinLockAccumulator.c

在MySQL INNODB 里怎么优化这个自旋锁

MySQL 在自旋锁抢锁的时候每次会调 ut_delay(底层会掉CPU指令,让CPU暂停一下但是不让出——避免上下文切换),发现性能好了几倍。这是MySQL 的官方文档:https://dev.mysql.com/doc/refman/5.7/en/innodb-performance-spin_lock_polling.html

所以我们继续在以上代码的基础上在自旋的时候故意让CPU pause(50个), 这个优化详细案例:https://plantegg.github.io/2019/12/16/Intel%20PAUSE%E6%8C%87%E4%BB%A4%E5%8F%98%E5%8C%96%E6%98%AF%E5%A6%82%E4%BD%95%E5%BD%B1%E5%93%8D%E8%87%AA%E6%97%8B%E9%94%81%E4%BB%A5%E5%8F%8AMySQL%E7%9A%84%E6%80%A7%E8%83%BD%E7%9A%84/

该你动手了

随便找一台x86 机器,笔记本也可以,macOS 也行,核数多一些效果更明显。只要有Java环境,就用我编译好的class、libpause.so 理论上也行,不兼容的话按代码那一节再重新编译一下

可以做的实验:

  • 重复我前面两个运行,看CPU 使用率以及最终耗时
  • 尝试优化待pause版本的自旋锁实现,是不是要比没有pause性能反而要好
  • 尝试让线程sleep 一下,效果是不是要好?
  • 尝试减少线程数量,慢慢是不是发现自旋锁版本的性能越来越好了

改变线程数量运行对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
//自旋锁版本线程数对总时间影响很明显,且线程少的话性能要比加锁版本好,这符合自旋锁的设定:大概率不需要抢就用自旋锁
#taskset -a -c 56-63 java -Djava.library.path=. SpinLockAccumulator 1 100000000
累加结果: 100000000
操作耗时: 2542 毫秒

#taskset -a -c 56-63 java -Djava.library.path=. SpinLockAccumulator 2 100000000
累加结果: 100000000
操作耗时: 2773 毫秒

#taskset -a -c 56-63 java -Djava.library.path=. SpinLockAccumulator 4 100000000
累加结果: 100000000
操作耗时: 4109 毫秒

#taskset -a -c 56-63 java -Djava.library.path=. SpinLockAccumulator 8 100000000
累加结果: 100000000
操作耗时: 11931 毫秒

#taskset -a -c 56-63 java -Djava.library.path=. SpinLockAccumulator 16 100000000
累加结果: 100000000
操作耗时: 13476 毫秒


//加锁版本线程数变化对总时间影响不那么大
#taskset -a -c 56-63 java -Djava.library.path=. LockAccumulator 16 100000000
累加结果: 100000000 and time:9074

#taskset -a -c 56-63 java -Djava.library.path=. LockAccumulator 8 100000000
累加结果: 100000000 and time:8832

#taskset -a -c 56-63 java -Djava.library.path=. LockAccumulator 4 100000000
累加结果: 100000000 and time:7330

#taskset -a -c 56-63 java -Djava.library.path=. LockAccumulator 2 100000000
累加结果: 100000000 and time:6298

#taskset -a -c 56-63 java -Djava.library.path=. LockAccumulator 1 100000000
累加结果: 100000000 and time:3143

设定100并发下,改变机器核数对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//16核机器跑3次 耗时稳定在12秒以上,CPU使用率 1600%
#taskset -a -c 48-63 java -Djava.library.path=. SpinLockAccumulator 100 10000000
累加结果: 10000000
操作耗时: 12860 毫秒

#taskset -a -c 48-63 java -Djava.library.path=. SpinLockAccumulator 100 10000000
累加结果: 10000000
操作耗时: 12949 毫秒

#taskset -a -c 48-63 java -Djava.library.path=. SpinLockAccumulator 100 10000000
累加结果: 10000000
操作耗时: 13692 毫秒

//8核机器跑3次,耗时稳定5秒左右,CPU使用率 800%
#taskset -a -c 56-63 java -Djava.library.path=. SpinLockAccumulator 100 10000000
累加结果: 10000000
操作耗时: 6773 毫秒

#taskset -a -c 56-63 java -Djava.library.path=. SpinLockAccumulator 100 10000000
累加结果: 10000000
操作耗时: 5557 毫秒

#taskset -a -c 56-63 java -Djava.library.path=. SpinLockAccumulator 100 10000000
累加结果: 10000000
操作耗时: 2724 毫秒

总结

以后应该不会再对升配后CPU 使用率也上去了,但是最终效率反而没变展现得很惊诧了

从CPU 使用率、上下文切换上理解自旋锁(乐观锁)和锁(悲观锁)

MySQL 里对自旋锁的优化,增加配置 innodb_spin_wait_delay 来增加不同场景下DBA 的干预手段

这篇文章主要功劳要给 ChatGPT4 ,里面所有演示代码都是它完成的

相关阅读

流量一样但为什么CPU使用率差别很大 同样也是跟CPU 要效率,不过这个案例不是因为自旋锁导致CPU 率高,而是内存延时导致的

今日短平快,ECS从16核升配到48核后性能没有任何提升(Netflix) 也是CPU 使用率高没有产出,cacheline伪共享导致的

听风扇声音来定位性能瓶颈

你要是把这个案例以及上面三个案例综合看明白了,相当于把计算机组成原理就学明白了。这里最核心的就是“内存墙”,也就是内存速度没有跟上CPU的发展速度,导致整个计算机内绝大多场景下读写内存缓慢成为主要的瓶颈

如果你觉得看完对你很有帮助可以通过如下方式找到我

find me on twitter: @plantegg

知识星球:https://t.zsxq.com/0cSFEUh2J

开了一个星球,在里面讲解一些案例、知识、学习方法,肯定没法让大家称为顶尖程序员(我自己都不是),只是希望用我的方法、知识、经验、案例作为你的垫脚石,帮助你快速、早日成为一个基本合格的程序员。

争取在星球内:

  • 养成基本动手能力
  • 拥有起码的分析推理能力–按我接触的程序员,大多都是没有逻辑的
  • 知识上教会你几个关键的知识点
image-20240324161113874